Forms: add conditional logic to form fields - #50938
Conversation
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! Jetpack plugin: The Jetpack plugin has different release cadences depending on the platform:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
Code Coverage SummaryCoverage changed in 29 files. Only the first 5 are listed here.
16 files are newly checked for coverage. Only the first 5 are listed here.
Full summary · PHP report · JS report Coverage check overridden by
I don't care about code coverage for this PR
|
There was a problem hiding this comment.
Pull request overview
Adds first-pass conditional logic support to Jetpack Forms fields (editor UI + front-end visibility + server-side enforcement), gated behind a single jetpack_forms_conditional_logic_enable flag to keep behavior consistent while the feature is tested.
Changes:
- Introduces a shared conditional-logic vocabulary + evaluator in TypeScript (editor/front-end) and a mirrored PHP evaluator for validation and storage.
- Updates front-end interactivity and field rendering to hide conditionally-invisible fields, and updates PHP validation/storage to skip/strip hidden fields.
- Adds comprehensive PHP + JS test coverage, including a parity test to prevent TS/PHP drift, plus a changelog entry.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php | Integration coverage for stripping hidden fields during feedback creation. |
| projects/packages/forms/tests/php/contact-form/Conditional_Logic_Validation_Test.php | Integration coverage for validation behavior (required + hidden regression). |
| projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php | Unit tests for PHP evaluator, mirroring JS cases. |
| projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php | Enforces TS/PHP parity (operators, type tables, cascade cap). |
| projects/packages/forms/tests/php/contact-form/Conditional_Logic_Feature_Flag_Test.php | Verifies the feature flag gates editor/runtime/enforcement together. |
| projects/packages/forms/tests/js/blocks/shared/conditional-logic/register.test.js | Tests editor filter registration + field coverage. |
| projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx | Tests panel behavior and rule-building UX. |
| projects/packages/forms/tests/js/blocks/shared/conditional-logic/operator-labels.test.js | Ensures every operator is labelled and labels are non-empty. |
| projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-types.test.js | Verifies block/type/operator/value-input mappings for all field blocks. |
| projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-options.test.js | Tests option extraction across the various field option storage schemes. |
| projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js | Unit tests for JS evaluator + cascade behavior. |
| projects/packages/forms/src/modules/form/view.js | Adds per-field derived state to compute conditional visibility in the interactivity store. |
| projects/packages/forms/src/contact-form/css/grunion.scss | Adds a CSS class to fully hide conditionally-hidden field wrappers. |
| projects/packages/forms/src/contact-form/class-feedback.php | Strips hidden fields from stored feedback data server-side. |
| projects/packages/forms/src/contact-form/class-contact-form.php | Emits conditional logic context; caches resolved visibility; skips validation for hidden fields. |
| projects/packages/forms/src/contact-form/class-contact-form-plugin.php | Serializes the conditionalLogic block attribute into a shortcode-compatible attribute. |
| projects/packages/forms/src/contact-form/class-contact-form-field.php | Decodes conditionallogic and binds conditional hiding to field wrapper via interactivity. |
| projects/packages/forms/src/contact-form/class-conditional-logic.php | New PHP conditional-logic evaluator + cascade resolver. |
| projects/packages/forms/src/class-jetpack-forms.php | Adds the single feature gate (jetpack_forms_conditional_logic_enable). |
| projects/packages/forms/src/blocks/shared/settings/index.js | Adds a shared conditionalLogic attribute default for form field blocks. |
| projects/packages/forms/src/blocks/shared/conditional-logic/util/operator-labels.ts | Editor-only translated operator labels. |
| projects/packages/forms/src/blocks/shared/conditional-logic/util/field-types.ts | Shared operator/type tables and helpers (block + shortcode type mapping). |
| projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts | Normalizes selectable options across choice-style fields. |
| projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts | New TS evaluator + cascade resolver used in editor/front-end. |
| projects/packages/forms/src/blocks/shared/conditional-logic/register.jsx | Registers the editor.BlockEdit filter to inject the panel into all field blocks. |
| projects/packages/forms/src/blocks/shared/conditional-logic/hooks/use-subject-fields.js | Discovers sibling fields and ensures stable IDs for condition subjects. |
| projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss | Styles for the inspector panel UI. |
| projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js | Control registry (phase 1: Field Value). |
| projects/packages/forms/src/blocks/shared/conditional-logic/controls/field-value/edit.jsx | Rule builder UI for field-value conditions. |
| projects/packages/forms/src/blocks/shared/conditional-logic/constants.js | Default + normalization helper for the attribute shape. |
| projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx | Inspector panel wrapper and top-level logic management. |
| projects/packages/forms/src/blocks/shared/conditional-logic/components/panel-header.jsx | Copy/paste/reset utilities and clipboard validation. |
| projects/packages/forms/src/blocks/field-text/edit.jsx | Clarifying docblock note that the conditional panel is injected via filter. |
| projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php | Exposes the feature flag to the editor via registered features. |
| projects/packages/forms/src/blocks/contact-form/child-blocks.js | Side-effect import to ensure editor filter registration is loaded. |
| projects/packages/forms/changelog/try-conditional-form-fields | Changelog entry for the new (flagged) conditional-logic feature. |
| // BaseControl still applies a bottom margin here even | ||
| // with __nextHasNoMarginBottom, which pushed the two | ||
| // selectors out of line with each other. | ||
| .components-base-control, | ||
| .components-base-control__field { | ||
| margin-block-end: 0; | ||
| } |
There was a problem hiding this comment.
Are they inheriting the margin from some odd stylesheet or what's going on there? They shouldn't have margin.
There was a problem hiding this comment.
Good question, and the answer is unsatisfying: both selectors already pass __nextHasNoMarginBottom, but BaseControl still emits the bottom margin in this context.
It isn't cosmetic — that margin is what made the two selectors sit visibly out of line with each other, which was reported as a bug during testing. So I've kept the override but added a comment explaining why it exists rather than leaving it looking arbitrary:
// Both selectors pass __nextHasNoMarginBottom, but
// BaseControl still emits a bottom margin here, which
// left the two boxes visibly out of line. Dropping it
// is what makes the row sit flush.If there's a correct prop or component I should be reaching for instead, I'd rather use that than override — happy to swap it out.
| @@ -0,0 +1,348 @@ | |||
| import { Button, Notice, SelectControl, TextControl } from '@wordpress/components'; | |||
There was a problem hiding this comment.
Recommend trying directly with Button and Notice from @wordpress/ui, easier when not needing to migrate later.
There was a problem hiding this comment.
Switched — Button now comes from @wordpress/ui, and the remove control uses IconButton, which looks like the right component for an icon-only action since it takes label for both the tooltip and assistive tech.
That detail mattered: swapping to a plain Button first silently dropped the button's accessible name, and two tests caught it.
Notice I've left on @wordpress/components for now — the rule-level notices rely on status="warning" and isDismissible, and I couldn't confirm the equivalents without the design-system docs to hand. Happy to move it in a follow-up if those map across.
|
Thanks for the review — all of it is addressed in 335d485. Design system. The panel now uses
One I pushed back on, the Also fixed: CI was red on One caveat worth stating: I didn't have the design-system documentation available while making these changes, so the component props and token names came from the installed |
PHPCS treats the double-arrow misalignment as a warning, and this repo's phpcs run exits non-zero on warnings, so it fails the build.
A rating was declared as a `number`, and every rule against one was silently dead. The field submits `selected/max` -- `4/5`, not `4` -- so is_numeric() and Number() both refuse it, the pair comes back unparseable, and every numeric operator returns false. "Show when the rating is at least 4" hid its field permanently, and the answer was then dropped at storage. Only is_empty and is_not_empty worked, because they never reach the numeric comparison. The same failure as image-select, and it was hiding in the same place: the type table said the comparison was numeric, and nothing checked what the field actually submits. Rating is its own type key now rather than a special case inside `number`, because two things differ, not one. The submitted value needs unpacking before it can be compared -- only the submitted side, since the rule stores a bare number. And the values worth offering are the field's own scale, so the rule builder lists 1..max from the block's `max` attribute instead of a free number box that would happily accept 6 stars out of 5. The operators are the numeric set, which is why sharing the key looked reasonable. Four cases in the shared behaviour table, so both evaluators are pinned to the same answers; they failed in both languages before this.
The rule builder lived in a column about 280px wide. Three controls per condition do not fit across it, so each condition stacked into a bordered card, and three or four of those were taller than the viewport -- on top of the action and match selectors and a hint line. The multi-group storage would have made it worse: a second group means two nested lists in that column. The inspector now keeps a summary and a button, and the rules are edited in a wide dialog. That follows jetpack-integration-controls, which already keeps a summary in the inspector and pushes its list into a Modal. With the width available, a condition is one row -- subject, comparison, value, remove -- reading as a sentence rather than a labelled card. Both selectors sit inside the sentence, so the whole rule reads back as "Show this field when all of these match". A long list becomes aligned columns instead of a stack. The summary is why the inspector keeps a panel rather than a bare button: an author can see whether a field is conditional, and roughly why, without opening anything. It states the action, the match mode and the count. Edits still commit straight to the block attribute. There is no draft state and no Save button, so undo remains the editor's own and the rules have one source of truth, matching every other inspector control. Nothing else moves: the stored shape, both evaluators, all the PHP, the isSelected gate, and the lazy boundary are untouched. The dialog renders inside the already-lazy panel chunk, so a site with the feature off still fetches none of it -- verified against a clean build, where editor.js contains no trace of the dialog. Two test changes worth noting. Most of the rule-editing tests needed only the dialog opened first, since they drive the same components. The two that queried through the render container now query the dialog instead, because a Modal portals to the end of the document rather than nesting in what render() returns.
Three changes to the conditional-logic dialog. The heading goes back to the arrangement it had in the inspector: the action and match selectors side by side, with the clause that finishes the sentence on its own line beneath. Folding both into a single inline sentence read tidily in a mockup but left the two controls competing with the prose between them; side by side they are the sentence, and the line underneath finishes it. Each condition now sits on its own tinted surface. In the inspector a border did that work, but a bordered card in a wide dialog reads as a box around nothing -- the tint separates the rows without drawing another outline. The remove control uses the trash icon rather than a cross. The dialog's own close button is a cross, and two crosses on screen doing different things is one too many.
…o content The controls in a condition row all sit on one line, so the row centres them rather than aligning to the top. The remove button also loses the top offset it carried: that offset existed to line it up against top-aligned controls, and with the row centred it was the thing knocking it out of line. The two heading selectors size to their own labels instead of splitting the row. They are words in a sentence, and stretching them across a wide dialog made a short phrase like "if any" span half the width, which stopped the line reading as a sentence at all -- the problem was only invisible in the inspector because there was no width to spread into. Also rewraps the comments in this file: SCSS here is checked at 80 columns rather than the 100 the JavaScript uses, and the rewrite had been running past it. My commits were using --no-verify, so the pre-commit stylelint never said so. The stacked branches are unaffected.
… with one Four changes to the conditional-logic dialog. The inspector button matches Manage integrations -- the components Button at secondary, not the design-system one. Two buttons opening two dialogs from the same inspector should not look like different kinds of thing. The builder opens with a condition waiting to be filled in, instead of an empty pane and an Add button. That row is not written to the block until a field is chosen, so opening the dialog does not mark the post as changed. A condition that names no subject, or gives no value where its operator needs one, is skipped by both evaluators. That was invisible: the field simply did not react, with nothing on screen saying why. Finished conditions now carry a tick at the head of the row, an unfinished one says it will be ignored, and Add condition waits until the current one says something -- otherwise an author can stack up rules that quietly do nothing. The judgement lives in one place and mirrors what the evaluators skip, so the editor and the runtime cannot drift on what counts as a condition. Only a started condition complains. An untouched row is empty, not wrong, and the builder now opens with one of those.
…on add The Edit conditions button centres in the panel rather than sitting against the left edge, so it reads as the panel's action rather than a stray control. The unfinished-condition message lines up with the control it is about. It was starting at the container edge while the controls start after the status column, which left it hanging under the tick. It now repeats that column as an empty spacer instead of computing an offset, so the two stay aligned if the column's width ever changes. Adding a condition moves focus to its field selector. A new row appears empty and the first thing to do with it is choose a subject; it also tells a screen-reader user the row is there at all. Only the row the button just made takes focus, so opening the dialog does not pull focus out of the editor. The focus test needed a harness holding real state. The existing one passes a jest.fn() as setAttributes, so an added condition never comes back as props and the second row never renders -- the assertion would have been about the mock rather than the component.
A field with conditions now carries a button beside Required: an eye when the rules show it, an eye with a slash when they hide it. Its tooltip is the same sentence the inspector summary uses, and pressing it opens the rule builder. It answers "what is special about this field?" for an author looking at the canvas rather than the sidebar, which is where the panel's summary is no help. Rendered from the same filter as the panel rather than from the blocks. The Required control is rendered in three places -- the shared field controls, and the consent and checkbox blocks, which build their own -- so adding a sibling there would have meant three edits and a fourth whenever another block goes its own way. The filter already covers every field block. Only shown once conditions exist: on a field without any there is no state to report, and a button on every field block in the form is noise. Both conditional-logic suites mock @wordpress/block-editor, so both needed BlockControls adding. One of them fails at import rather than in a test, which reads as an unrelated suite breaking.
…xist Matches how Required marks a field: the is-pressed class, which the toolbar renders as an inverted icon. Two toolbar buttons reporting a field's state should look like the same kind of thing. Note this is always on today, because the button only renders once conditions exist -- there is no un-inverted state to see. Keeping the condition explicit rather than hardcoding the class is what makes an always-visible version a one-line change if the button should sit in the toolbar the way Required does, present whether or not it applies.
…supports it The button was appearing only once rules existed, which made it useless for finding the feature -- an author had to already know it was there. It now sits in the toolbar for any block that supports conditional logic, the way Required does, and inverts once the field carries conditions. That also makes it a way in: pressing it opens the builder whether or not rules exist, so conditional logic is reachable from the canvas rather than only from the sidebar. Its tooltip is "Add conditional logic" while the field has none. It cannot be "Conditional logic": that is the inspector panel's title, so a query for a button of that name matches both, which is worth avoiding in the accessibility tree as well as in the tests.
Four changes to the rule builder. Clear all conditions removes them in one go, sitting opposite Add rather than beside it so the two are not mistaken for each other. It appears only once there is something to clear. Clearing does not immediately offer another empty row. The builder normally opens with one waiting, but doing that straight after a clear looks like the clear failed -- an author who wants a row presses Add. Add condition is now absent rather than disabled while the current condition is unfinished. A disabled button plus a line explaining why is more to read than a button that is simply not there yet, so the explanatory line goes with it. The inspector's Edit conditions button spans the panel instead of centring, so it reads as the panel's action rather than a control floating in the middle.
Adding a condition was blocked while the current one was unfinished, which made it impossible to add a second one mid-thought -- a normal way to work, and the bug reported here. Add condition is always available now. What the gate was protecting against is instead said per row. A complete condition is badged Active; one that is incomplete is badged Inactive and carries the reason: no field chosen, a field that has since been deleted, or a missing value. That is more useful than the gate was, because it names the row at fault rather than blocking the whole builder. The reason is set on the badge as well as in its tooltip. A tooltip renders nothing until hovered, so on its own it leaves the reason unreachable by keyboard and unread by a screen reader. The tick is gone. It told an author nothing they could act on, and now that the badge carries both states there is no need for a second signal.
…o content The badge was flexing like a fourth column, taking room from the three selectors that carry the condition's meaning. It now takes only the width its own text needs, and sits after the remove control at the end of the row. Widths default to content for everything in the row, with the three selectors sharing out what is left. Anything added to the row later will size to its content rather than starting to stretch on its own.
The line after the selectors read "of the following conditions are met:", which finished the sentence but left the starting state unsaid. A field with a show rule is hidden until something reveals it, and one with a hide rule is visible until something hides it -- an author had to infer that. It now states the default, and follows the action: "This field is hidden by default, until the following conditions are met:", or visible for a hide rule.
The icons were the wrong way round. A show rule means the field is hidden until something reveals it, so the toolbar showed an open eye for a field that starts hidden. The icon now reports the state an author actually sees on the canvas before any condition is met: hidden for a show rule, visible for a hide rule. A field with no conditions reads as visible, which the plain action check would have got wrong -- the action defaults to `show`, so an untouched field would have claimed to be hidden. That question is now named rather than decided inline. The toolbar icon and the builder's opening line both report the same thing, so having one predicate for it is what stops them disagreeing. Tested against the predicate rather than the rendered icon. Reading an SVG path out of the DOM meant manual cleanup and direct node access, both of which the testing-library rules reject, and it pinned the shape of an icon rather than the meaning behind it.
Badges at the end of the row said Active or Inactive in words. An icon at the head says the same thing in less space, and puts the state where it can be read straight down the left edge of a long list rather than hunted for at the end of each line. A green check means the condition will be acted on; an amber caution means it will be skipped. The tooltip carries the reason, phrased as the thing to do about it: choose a field, give it a value, or replace one whose field has since been deleted. The reason is set on the icon as well as in its tooltip. A tooltip renders nothing until hovered, so on its own it leaves the only explanation of why a rule will not fire unreachable by keyboard and unread by a screen reader.
panel.test.jsx mocks useSubjectFields and useEnsureFieldId so it can drive the rule builder without a block editor. That left what they actually implement untested: which fields are offered as subjects, and what id a chosen one is given. The id assignment is the part worth pinning -- getting it wrong silently repoints a rule at another field, or renames a field that may already have responses stored against its old id. Covers minting an id from a label, keeping an explicit one, de-duplicating against ids already in the form, the empty-slug fallback, and a missing field. For useSubjectFields: sibling fields listed, the panel's own excluded, id-less fields kept, step numbering, the label fallbacks, and a field outside any form. The panel suite gains the one assertion it could not make with a fixture that had no id of its own -- that the panel's own field id reaches the uniqueness check. Without it an unnamed sibling whose label slugifies the same way is handed the owner's id. Both guards verified by breaking them: dropping the used-id list fails the de-duplication tests, and dropping ownFieldId fails the panel one. Equivalent ground to kraftbj's tests on #50980, written against the UI as it now stands rather than cherry-picked -- the panel those assertions drove has since moved into a dialog.
The status icons were reading as near-black. The unsuffixed success and warning foreground tokens are text-on-light colours -- rgb(0,41,0) and rgb(46,25,0), dark enough that neither icon looked like its status. The `-weak` variants are the ones with the hue: green for a condition that will be acted on, amber for one that will be skipped. Caught by looking at a real screenshot; both spellings compile and pass stylelint, so nothing else would have flagged it.
Code reviewFound 1 issue:
jetpack/projects/packages/forms/src/contact-form/class-conditional-logic.php Lines 385 to 388 in 5898fc1 The JS mirror has the same behaviour, so the two evaluators agree with each other and only disagree with the UI: Either make the evaluators genuinely skip a rule whose operator needs a value it was not given, or change the editor copy so it stops promising something the runtime does not do. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
The editor said an unfinished condition would be ignored. Both evaluators substituted an empty string for the missing value and evaluated it for real, so the amber icon and its "Give this condition a value." tooltip were promising something that did not happen. Evaluating such a rule is worse than useless. `does_not_contain ''` is true of every value, so a half-written rule quietly forced its field visible; `is ''` matched whenever the subject happened to be blank, firing by accident. Either way a condition the editor marked inert was deciding what the visitor saw. An operator that compares against something, given nothing to compare against, now returns null in both languages -- the same "ignore this rule" the evaluators already use for a subject field that no longer exists. isRuleComplete loses its exception for subjects that render no value input. An operator needing a value it cannot be given is exactly as inert as one the author simply has not filled in, and both evaluators now skip both; keeping the exception would have put the icon back at odds with what happens. Zero is a value, not a missing one. Both sides compare the stringified form, so a rule against 0 is unaffected -- covered in the shared table, along with the two operators above. Also reattaches two docblocks that earlier commits on this branch separated from their functions by inserting a new method in between, one of which phpcs was failing on.
|
Fixed in bf8fd6d. Went with making the evaluators honour what the editor says, rather than softening the copy — a rule the author hasn't finished shouldn't be deciding what a visitor sees. An operator that compares against something, given nothing to compare against, now returns
Four rows added to the shared behaviour table, so both evaluators are pinned to the same answers:
Verified by removing the guard again and watching two of those rows fail. |
The production build failed: "msgid argument is not a string literal: __(x?'Edit conditions':'Add conditions','jetpack-forms')". Two identically shaped __() calls in a ternary get folded by the minifier into one call whose msgid is an expression, and an expression cannot be extracted for translation. Restructuring does not help -- an if/else, and separate object properties, both get folded back into the same call. The package already solves this by giving one branch a third argument, which __() ignores at runtime but the minifier cannot merge across. Same fix here, in the two places this branch introduced, with a comment so the stray argument does not read as a typo. Also realigns the double arrows in the conditional-logic test fixtures. This repo's phpcs run exits non-zero on warnings, and the scripted edit that moved those tests onto the groups shape left them misaligned.
kraftbj
left a comment
There was a problem hiding this comment.
I pushed c31bbd6 to fix the conditional-logic blockers I found: browser and PHP values now agree for implicit/explicit consent, unchecked checkbox controls, and filtered hidden subjects; submitted hidden values are not filtered twice; and MailPoet, Hostinger Reach, and Post To URL consume only visible fields across legacy and structured feedback formats.
Before merging, please specifically check the value normalization around implicit/explicit consent and filtered hidden subjects, plus the v1 versus v2/v3/unversioned structured integration split. Those are the places where I made behavioral judgment calls. The Forms PHP and JS suites, PHPCS, and Phan pass locally.
… them
The summary said "Shown when 2 conditions match", which tells an author how many
rules there are but not what they say -- so the only way to find out was to open
the dialog, which is what the summary exists to avoid.
It now reads:
This field is shown only if:
Phone is “iPhone”
Email is not empty
Each line is the sentence the author built, in the same words the rule builder
uses for it, so the two cannot describe the same rule differently.
Only the conditions that will actually be acted on are listed. An incomplete
rule is skipped by both evaluators, so including it would describe behaviour the
field does not have.
The heading is four separate strings rather than one assembled from fragments:
"shown"/"hidden" and "only if"/"any of these" do not slot into every language the
same way, and a sentence built by concatenation cannot be reordered by a
translator.
The toolbar tooltip gets the same summary on one line, from the same functions,
so the canvas and the sidebar cannot drift.
The conditions are marked up as a list, so a screen reader announces how many
there are before reading them.
… hover The summary named a field by its label alone. Most fields carry no explicit id, several may share a label, and one with no label at all reads "Untitled field" -- so the line often did not identify which field it meant. It now reads `Name (Name field) is “lol”`, the same way the subject dropdown names it. That formatting moves into one function the dropdown and the summary both use, so the same field cannot be named two different ways in the same panel. Hovering a condition highlights its field on the canvas, using the store action the block list uses for the same gesture. Pointing at a condition now shows you which field it refers to instead of leaving you to find it by name. The highlight is cleared on unmount as well as on mouseleave: selecting another block unmounts the panel, and mouseleave never arrives. The two suites that mock @wordpress/block-editor now also mock useDispatch. The real module has to be loaded before the mock is registered -- a factory that imports the module it is mocking recurses until V8 gives up, and one that omits the store helpers fails at import rather than in a test.
Deleting conditions one at a time is already possible from each row, and a second destructive control beside Add gave the dialog two competing actions for a list that is rarely more than a few rows long. The state that tracked a deliberate clear goes with it. It existed only to stop the waiting row reappearing the moment the list was emptied, which made a clear look like it had failed -- with no clear button there is nothing to suppress, and an empty list means the author has removed the last row and is offered a fresh one, which is what they would want next.



See FORMS-744
Proposed changes
Adds conditional logic to Jetpack form fields: any field can be shown or hidden based on
another field's answer.
Disabled by default. The whole feature sits behind the
forms-conditional-logicfeatureflag, registered with the
automattic/jetpack-feature-flagspackage, while we test it. Note this is that package's first consumer.
Rules are edited in a dialog rather than the inspector. Three controls per condition do not fit
a ~280px column without stacking into a card each, and a handful of those outgrows the viewport.
A condition that names no field, or gives no value where its operator needs one, is skipped by
both evaluators. That used to be invisible — the field simply did not react. The icon at the
head of each row now says which conditions will be acted on, and the tooltip on an amber one
says what to do about it.
The inspector keeps a summary and a button, so a field's behaviour is readable without opening
anything. The block toolbar carries the same state next to Required: an eye, crossed out
when the field starts hidden.
editor.BlockEditfilteradds the panel to each
jetpack/field-*block that declares a comparison behaviour — 18 ofthe 19. A filter rather than per-block wiring because the field blocks share no single
inspector component (four build their own), and new field types inherit it automatically.
Each block declares its own
conditional_logic.typebesideform_editor, so support can beturned on one block at a time.
their own options, numeric fields get
>/</≥/≤, date and time get before/after,checkboxes get is/is-not-checked, and a rating offers its own scale. Entries are labelled
with their block type —
Name (Name field)— so fields are distinguishable without a label.rules, resolved to a fixed point. On a cycle the field is left visible: a stray value in a
response is recoverable, a silently discarded answer is not.
validation, storage, the notification email and the integrations from one map, so they cannot
disagree about whether a field was shown.
Storage
An array of groups, not a map keyed by condition kind. A map cannot express "any of these AND
all of those", so supporting more than one grouping later would have meant reshaping what is
already stored. Both evaluators handle several groups today even though the UI writes exactly
one — otherwise the second group would still arrive needing an evaluator change.
Rules carry their own
type, so further condition kinds (query string, user role, date andtime) become new rule types inside a group rather than another reshape. A rule of an unknown
type is ignored, so a form saved by a newer editor degrades to its remaining conditions.
Those extra condition kinds are deliberately out of scope: they are decided once at render, but
a submission does not carry the request context that produced it, so PHP cannot re-derive them
at submit time without a signed render-time payload. That needs its own design.
Related product discussion/links
Does this pull request change what data or activity we track or use?
No new tracking. It does change what is stored in a form response: fields hidden by conditional
logic are excluded from the stored response, the notification email, and the payload passed to
integrations — which is the point of the feature. Nothing is recorded that was not submitted.
Testing instructions
Conditional logic is off by default. Enable the flag with a mu-plugin:
Editor
checkbox and a radio field.
the block toolbar beside Required — both on every field type, including radio and
checkbox-multiple. (Image-select is deliberately excluded; see below.)
is greater than/is at least;confirm it re-offers 1–3;
is checkedand shows no value box.and the field should behave as if the rule were not there.
half-written rule must not block adding another.
editor (Jetpack → Forms), which load different bundles.
Front end
Choose "Other", fill B, then change A away. Both B and C should hide — C must not survive on
B's leftover value.
Submission should go through. Make it visible and submit empty — it should now block.
dd/mm/yyand build a rule on it. The browser and the storedresponse must agree. Worth doing with your machine's timezone away from UTC.
must disappear, and its answer must not be stored.
Flag off
validates normally, and nothing is stripped from responses. With DevTools → Network filtered
to
conditional-logic, nothing should be fetched: the rule builder lives in a lazily-loadedchunk that is never requested when the flag is off.
Notes for reviewers
Behaviour worth a second opinion
Most fields have no
id: the renderer derives one from the label at output time, and a rulepointing at a derived id would stop matching the moment someone edited that label. On a form
that already has responses, this changes that field's response key going forward.
choice, not the label the rule builder offers, so a rule against it could never match.
Comparing the decoded label in all three evaluators is the real fix, but that is value-shape
handling in exactly the place the two evaluators already drift.
too much is recoverable in a way that one showing too little is not.
Fixed on this branch
field they could not see.
conditional field, an invalid email or an out-of-allow-list choice were accepted and stored.
[and a bare
<broke the value out of the attribute, dropping the condition while leaving thefield required.
prefilled field the visitor cleared could be validated as visible and then have its answer
dropped as hidden.
grunion_after_feedback_post_inserted— the last of which MailPoet reads for consent.Date.parse()reads a bareYYYY-MM-DDas UTC and
mm/dd/yyas local, whilestrtotime()reads both as site-local. Add/mm/yyfield parsed on neither side. Both now parse explicitly using the field's own format.
selected/max, e.g.4/5, not a number.in, and had their answers discarded server-side.
circular and failed open.
outer wrapper.
should have been revealed.
Implementation notes
field-single-choice/isjetpack/field-radio,field-multiple-choice/isjetpack/field-checkbox-multiple. A hand-written type table got both wrong, soblock-names.test.jsderives names from source rather than restating them.dist/blocks/editor.jsanddist/form-editor/jetpack-form-editor.js,and the Forms editor loads both. Registration is guarded with
hasFilter, sinceaddFilterdoes not de-duplicate by namespace.
subject list, and mounting it on every field made that walk run per field on every store
change.
other pins comparison behaviour from a table both suites read — which is where the two
evaluators actually drift. The date bug above passed the vocabulary test.
automattic/jetpack-feature-flags. Adding the dependencycascaded into
projects/plugins/jetpack/composer.lock, committed here since CI runs--frozen-lockfile.